package at.ac.tuwien.dse.health.messenger;

import at.ac.tuwien.dse.health.messenger.annotation.Dynamic;
import com.google.common.base.Throwables;
import com.google.common.collect.Maps;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.util.MethodInvoker;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Predicates.notNull;
import static com.google.common.collect.Collections2.filter;
import static com.google.common.collect.Collections2.permutations;
import static com.google.common.collect.Lists.newArrayList;
import static org.apache.commons.lang3.ArrayUtils.isEmpty;
import static org.apache.commons.lang3.ArrayUtils.nullToEmpty;
import static org.apache.commons.lang3.ClassUtils.*;
import static org.springframework.util.ReflectionUtils.*;

/**
 * Dynamically resolves and invokes methods based on the provided arguments.
 * <p/>
 * A method is considered to be a potential candidate for method invocation if it returns a non-void value and is
 * annotated with {@link Dynamic}.<br/>
 * To narrow down the amount of methods to be processed, the objects have to be registered in advance and their dynamic
 * methods are cached within this instance.<br/>
 * The method to invoke is determined by comparing the types of the provided non-null parameters with the parameter
 * types of the method signatures. If all provided parameters are assignable to the parameter types of the method in
 * arbitrary order, the method is invoked after preparing the method invocation i.e., sorting the parameters.
 * <p/>
 * <b>Note that method signatures must be distinct completely.</b><br/>
 * Thus two method signatures are treated to be equal if the number of parameters are equal and at least one of the
 * following conditions apply:
 * <ul>
 * <li>the unqualified method names are identical or</li>
 * <li>both methods have the same parameter types (primitive types are equal to their wrapper counterpart) or</li>
 * <li>the signatures of both methods are identical except their parameter order or</li>
 * <li>one or more parameters are subtypes of the corresponding parameters of the second method</li>
 * </ul>
 *
 * If two methods are registered for which at least one of these conditions is fulfilled, the first method found it
 * taken if possible. Otherwise an {@link IllegalArgumentException} is thrown.
 * <p/>
 * For example, the following method signatures <b>are equal</b> because their parameter types are subtypes or their
 * method names are identical:<br/>
 * <pre class="code">
 * public String add(String a, String b) { ... }
 * public Integer add(Integer a, Integer b) { ... }
 * public long multiply(int a, int b) { ... }
 * public Number max(Number a, Number b) { ... }
 * public Object equals(Object a, Object b) { ... }
 * </pre>
 * Furthermore, method signatures with different parameter order <b>are equal</b> as well:<br/>
 * <pre class="code">
 * public String getString(Object o, String s) { ... }
 * public Date parse(String s, DateFormat f) { ... }
 * </pre>
 *
 * @author Bernhard Nickel
 * @author Gregor Schauer
 * @author Dominik Strasser
 */
public class DynamicMethodInvoker implements ApplicationContextAware {
	Map<List<Class<?>>, MethodInvoker> invokerMap = Maps.newHashMap();

	public DynamicMethodInvoker() {
	}

	public DynamicMethodInvoker(Object... entries) {
		for (Object entry : nullToEmpty(entries)) {
			register(entry);
		}
	}

	/**
	 * Invokes a certain method that can be invoked using the given parameters.
	 *
	 * @param args the method parameters (must not be {@code null} or empty)
	 * @return the result of the method invocation
	 * @throws IllegalArgumentException if the method resolution fails
	 */
	public Object invoke(Object... args) {
		checkArgument(!isEmpty(args), "No parameters provided.");
		Collection<Object> filter = filter(newArrayList(args), notNull());
		args = filter.toArray(new Object[filter.size()]);
		checkArgument(!isEmpty(args), "No non-null parameters provided.");

		ArrayList<Class<?>> types = newArrayList(toClass(args));
		MethodInvoker invoker = invokerMap.get(types);
		checkArgument(invoker != null, "Cannot find dynamic method for arguments: %s", types);

		synchronized (invoker) {
			try {
				if (invoker.isPrepared()) {
					invoker.setArguments(sortArguments(invoker.getPreparedMethod(), args));
				} else {
					Method method = findMethod(invoker.getTargetClass(), invoker.getTargetMethod(), (Class<?>[]) null);
					if (method == null) {
						throw new IllegalArgumentException(String.format("Cannot find method '%s.%s'",
								invoker.getTargetClass(), invoker.getTargetMethod()));
					}

					invoker.setArguments(sortArguments(method, args));
					invoker.prepare();
				}
				return invoker.invoke();
			} catch (Exception e) {
				throw Throwables.propagate(e);
			} finally {
				invoker.setArguments(null);
			}
		}
	}

	/**
	 * Looks for methods annotated with {@link Dynamic} and registers them as potential candidates.
	 *
	 * @param obj the object to register
	 */
	public void register(final Object obj) {
		doWithMethods(obj.getClass(), new MethodCallback() {
					@Override
					public void doWith(Method method) {
						MethodInvoker invoker = new MethodInvoker();
						invoker.setTargetObject(obj);
						invoker.setTargetMethod(method.getName());

						ArrayList<Class<?>> paramTypes = newArrayList(primitivesToWrappers(method.getParameterTypes()));
						for (List<Class<?>> types : permutations(paramTypes)) {
							invokerMap.put(types, invoker);
						}
					}
				}, new MethodFilter() {
					@Override
					public boolean matches(Method method) {
						return method.isAnnotationPresent(Dynamic.class);
					}
				}
		);
	}

	/**
	 * Sorts the provided arguments to fit to the method signature.
	 *
	 * An argument can be used only once and only if it is assignable to the declaring type of the parameter.<br/>
	 * If no argument can be found for a certain parameter, an {@link IllegalArgumentException} is thrown.
	 *
	 * @param method the method to invoke
	 * @param args the arguments provided
	 * @return the sorted arguments
	 * @throws IllegalArgumentException if an error occurs during parameter assignment
	 */
	Object[] sortArguments(Method method, Object... args) {
		if (method.getParameterTypes().length != args.length) {
			throw new IllegalArgumentException(String.format("Method '%s' takes %d arguments but %d were provided.",
					method, method.getParameterTypes().length, args.length));
		}

		List<Object> available = newArrayList(args);
		Object[] params = new Object[args.length];
		for (int i = 0; i < params.length; i++) {
			for (Object a : available) {
				if (isAssignable(method.getParameterTypes()[i], a.getClass(), true)) {
					params[i] = a;
					available.remove(a);
					break;
				}
			}
			if (params[i] == null) {
				String m = String.format("Cannot find parameter at index '%d' for method '%s'", i, method);
				throw new IllegalArgumentException(m);
			}
		}
		return params;
	}

	/**
	 * Registers all possible candidates for dynamic method invocation.<br/>
	 *
	 * @param applicationContext the application context to scan
	 * @see #register(Object)
	 */
	@Override
	public void setApplicationContext(ApplicationContext applicationContext) {
		Map<String, Object> beanMap = applicationContext.getBeansWithAnnotation(Dynamic.class);
		for (Object bean : beanMap.values()) {
			register(bean);
		}
	}
}
